/**
 * Copyright (C) Olafur Gauti Gudmundsson
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.solrom;

import com.thoughtworks.xstream.XStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import net.sf.beanlib.hibernate3.Hibernate3BeanReplicator;
import org.apache.commons.codec.binary.Base64;
import org.apache.solr.common.SolrDocument;
import org.apache.solr.common.SolrDocumentList;
import org.apache.solr.common.SolrInputDocument;
import org.solrom.annotations.IndexDocument;
import org.solrom.annotations.IndexField;
import org.solrom.annotations.IndexFieldGetter;
import org.solrom.annotations.IndexFieldSetter;
import org.solrom.annotations.IndexSerializedField;

/**
 * This is the main entry class for SOLROM.
 * To use SOLROM, you create an instance of this class, and add the classes
 * you want to be mapped to it. Solrom will validate that the classes you add
 * can be mapped to/from Solr documents.
 * 
 * <p>Instances of this class are thread-safe.</p>
 * 
 * @author Olafur Gauti Gudmundsson
 */
public class Solrom {
    
    private static final XStream XSTREAM = new XStream();

	private final boolean dynamicInstantiation;
	private final ConcurrentHashMap<Class, MappedClass> mappedClasses;
    private final DocumentProcessor docProcessor;
	
	/**
	 * Create a new Solrom instance with dynamic instantiation turned on.
	 */
	public Solrom() {
		this(true, null, null);
	}
    
    public Solrom( DocumentProcessor docProcessor ) {
        this(true, null, docProcessor);
    }
	
	/**
	 * Create a new Solrom instance.
	 * 
	 * @param dynamicInstantiation specifies whether to enable dynamic instantiation
	 */
	public Solrom( boolean dynamicInstantiation ) {
		this(dynamicInstantiation, null, null);
	}
    
	public Solrom( boolean dynamicInstantiation, DocumentProcessor docProcessor ) {
		this(dynamicInstantiation, null, docProcessor);
	}
	
	/**
	 * Create a new Solrom instance with dynamic instantiation turned on.
	 * 
	 * @param classesToMap a set of classes to map by this instance
	 */
	public Solrom( Set<Class> classesToMap ) {
		this(true, classesToMap, null);
	}
    
	public Solrom( Set<Class> classesToMap, DocumentProcessor docProcessor ) {
		this(true, classesToMap, docProcessor);
	}
	
	/**
	 * Create a new Solrom instance.
	 * 
	 * @param dynamicInstantiation specifies whether to enable dynamic instantiation
	 * @param classesToMap a set of classes to map by this instance
     * @param docProcessor a DocumentProcessor for handling all input documents
	 */
	public Solrom( boolean dynamicInstantiation, Set<Class> classesToMap, DocumentProcessor docProcessor ) {
		this.dynamicInstantiation = dynamicInstantiation;
		this.mappedClasses = new ConcurrentHashMap<Class, MappedClass>();
		if ( classesToMap != null ) {
			for ( Class c : classesToMap ) {
				map(c);
			}
		}
        this.docProcessor = docProcessor;
	}
	
	/**
	 * Get a set of all classes that are mapped by this instance.
	 *
	 * @return all classes that are mapped by this instance
	 */
	public Set<Class> getMappedClasses() {
		return Collections.unmodifiableSet(mappedClasses.keySet());
	}
	
	/**
	 * Check whether a specific class is mapped by this instance.
	 * 
	 * @param c the class we want to check
	 * @return true if the class is mapped, else false
	 */
	public boolean isMapped( Class c ) {
		return mappedClasses.containsKey(c);
	}
	
	/**
	 * Add a class that this instance can map to/from Solr documents. This method
	 * will validate that the class is correctly annotated.
	 * 
	 * @param c the class that will be mapped
	 * @return the Solrom instance
	 */
	public Solrom map( Class c ) {
		mappedClasses.putIfAbsent(c, Validator.validate(c));
		return this;
	}
	
	/**
	 * Get the set of field names that Solr needs to retrieve from the index
	 * to be able to map the class(es) supplied. This is useful when creating
	 * a Solr query, it means we don't have to maintain the field names
	 * manually.
	 * 
	 * @param classes the classes for which we want the query fields
	 * @return a set of all field names needed to map the classes specified
	 */
	public Set<String> getQueryFieldNames( Class... classes ) {
		Set<String> fieldNames = new HashSet<String>();
		for ( Class c : classes ) {
			if ( !isMapped(c) ) {
				throw new IndexFieldMappingException("This class is not mapped by Solrom: " + c.getName());
			}
			fieldNames.addAll(mappedClasses.get(c).getQueryFieldNames());
		}
		return fieldNames;
	}
    
    public Set<String> getQueryFieldNamesForAllClasses() {
		Set<String> fieldNames = new HashSet<String>();        
		for ( MappedClass c : mappedClasses.values() ) {
			fieldNames.addAll(c.getQueryFieldNames());
		}
		return fieldNames;
    }
	
	/**
	 * Read the document ID from the object supplied.
	 * 
	 * @param obj the object containing the ID
	 * @return the Solr document ID from the object 
	 * @throws org.solrom.IndexFieldMappingException
	 */
	public Object getId( Object obj ) throws IndexFieldMappingException {
		if ( !isMapped(obj.getClass()) ) {
			throw new IndexFieldMappingException("This class is not mapped by Solrom: " + obj.getClass().getName());
		}
		// process the fields
		for ( Field field : Utils.getDeclaredAndInheritedFields(obj.getClass()) ) {
			field.setAccessible(true);
			if ( field.isAnnotationPresent(IndexField.class) ) {
				IndexField indexField = field.getAnnotation(IndexField.class);
				if ( indexField.id() ) {
					try {
						return field.get(obj);
					} catch ( IllegalAccessException iae ) {
						throw new IndexFieldMappingException(iae);
					}
				}
			}
		}
		
		// then process the getter methods
		for ( Method method : obj.getClass().getMethods() ) {
			if ( method.isAnnotationPresent(IndexFieldGetter.class) ) {
				IndexFieldGetter searchField = method.getAnnotation(IndexFieldGetter.class);
				if ( searchField.id() ) {
					try {
						return method.invoke(obj);
					} catch ( IllegalAccessException iae ) {
						throw new IndexFieldMappingException(iae);
					} catch ( InvocationTargetException ite ) {
						throw new IndexFieldMappingException(ite);
					}
				}
			}
		}
		return null;
	}
	
	/**
	 * Maps the object supplied to a SolrInputDocument.
	 * 
	 * @param obj the object to be mapped
	 * @return a SolrInputDocument with fields read from the object
	 * @throws org.solrom.IndexFieldMappingException
	 */
	public SolrInputDocument toDocument( Object obj ) throws IndexFieldMappingException {
		if ( !isMapped(obj.getClass()) ) {
			throw new IndexFieldMappingException("This class is not mapped by Solrom: " + obj.getClass().getName());
		}
		
		SolrInputDocument doc = new SolrInputDocument();
		
		// store the class name
		IndexDocument indexDoc = Utils.getIndexDocumentAnnotation(obj.getClass());
		if ( indexDoc != null && !indexDoc.classNameField().equals("none") ) {
			doc.addField(indexDoc.classNameField(), obj.getClass().getCanonicalName());
		}
		
		// process the annotated fields
		for ( Field field : Utils.getDeclaredAndInheritedFields(obj.getClass()) ) {
			field.setAccessible(true);
			if ( field.isAnnotationPresent(IndexField.class) ) {
				IndexField indexField = field.getAnnotation(IndexField.class);
				String name = field.getName();
				if ( !indexField.name().equals("fieldName") ) {
					name = indexField.name();
				}
				
				// add the field value to a field in the document
				try {
                    Object value = field.get(obj);
                    if ( value != null ) {
                        if ( field.getType().isEnum() ) {
                            doc.addField(name, value.toString(), indexField.boost());
                        } else {
                            doc.addField(name, value, indexField.boost());
                        }
                    }
				} catch ( IllegalAccessException iae ) {
					throw new IndexFieldMappingException(iae);
				}
                
			} else if ( field.isAnnotationPresent(IndexSerializedField.class) ) {
				IndexSerializedField indexField = field.getAnnotation(IndexSerializedField.class);
				String name = field.getName();
				if ( !indexField.name().equals("fieldName") ) {
					name = indexField.name();
				}
                
                // serialize and store in index
				try {
                    Object value = field.get(obj);
                    if ( value != null ) {
                        if ( indexField.toXML() ) {
                            doc.addField(name, xmlSerializeField(value, field.getType()));
                        } else {
                            doc.addField(name, serializeField(value, field.getType()));
                        }
                    }
				} catch ( IllegalAccessException iae ) {
					throw new IndexFieldMappingException(iae);
				} catch ( IOException ioe ) {
                    throw new IndexFieldMappingException(ioe);
                }
            }
		}
		
		// process the annotated getter methods
		for ( Method method : obj.getClass().getMethods() ) {
			if ( method.isAnnotationPresent(IndexFieldGetter.class) ) {
				IndexFieldGetter indexField = method.getAnnotation(IndexFieldGetter.class);
				String name;
				if ( !indexField.name().equals("fieldName") ) {
					name = indexField.name();
				} else {
					name = Utils.getGetterPropertyName(method);
				}
				
				// invoke the method and add the result to a field in the document
				try {
                    Object value = method.invoke(obj);
                    if ( value != null ) {
                        if ( method.getReturnType().isEnum() ) {
                            doc.addField(name, value.toString(), indexField.boost());
                        } else {
                            doc.addField(name, value, indexField.boost());
                        }
                    }
				} catch ( IllegalAccessException iae ) {
					throw new IndexFieldMappingException(iae);
				} catch ( InvocationTargetException ite ) {
					throw new IndexFieldMappingException(ite);
				}
			}
		}
        
        // hand over to document processor
        if ( docProcessor != null ) {
            docProcessor.process(doc);
        }
		
		return doc;
	}
    
    private String xmlSerializeField( Object obj, Class c ) {
        Hibernate3BeanReplicator replicator = new Hibernate3BeanReplicator();
        return XSTREAM.toXML(replicator.deepCopy(obj, c));
    }
    
    private Object xmlDeSerializeField( String xml ) {
        return XSTREAM.fromXML(xml);
    }
    
    private String serializeField( Object obj, Class c ) throws IOException {
        Hibernate3BeanReplicator replicator = new Hibernate3BeanReplicator();
        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
        ObjectOutputStream serializer = new ObjectOutputStream(byteStream);
        serializer.writeObject(replicator.deepCopy(obj, c));
        serializer.flush();
        
        return new String(Base64.encodeBase64(byteStream.toByteArray()));
    }
    
    private Object deSerializeField( String objAsString ) throws IOException, ClassNotFoundException {
        ByteArrayInputStream byteStream = new ByteArrayInputStream(Base64.decodeBase64(objAsString.getBytes()));
        ObjectInputStream deserializer = new ObjectInputStream(byteStream);
        
        return deserializer.readObject();
    }
	
	private Object createInstance( Class c, SolrDocument doc ) 
			throws ClassNotFoundException, InstantiationException, IllegalAccessException {
		
		if ( dynamicInstantiation ) {
			// try to retrieve name of class to instantiate from the document
			IndexDocument indexDoc = Utils.getIndexDocumentAnnotation(c);
			String classNameField = "className";
			if ( indexDoc != null && !indexDoc.classNameField().equals("none") ) {
				classNameField = indexDoc.classNameField();
			}
			String className = (String)doc.getFieldValue(classNameField);
			if ( className != null ) {
				Class entityClass = Class.forName(className);
				if ( !isMapped(entityClass) ) {
					throw new IndexFieldMappingException("This class is not mapped by Solrom: " + entityClass.getName());
				} else {
					return entityClass.newInstance();
				}
			} else {
				// use default class
				return c.newInstance();
			}
		} else {
			// use default class
			return c.newInstance();
		}
	}

	/**
	 * Create an object from a SolrDocument.
     * All serialized fields will be loaded.
	 * 
	 * @param entityClass the class of the entity to be instantiated from the node
	 * (in the case of dynamic instantiation, the instance class may be read from
	 * the document, but will be cast to this class)
	 * @param doc the SolrDocument read from the index
	 * @return an object created from the SolrDocument
	 * @throws org.solrom.IndexFieldMappingException
	 */
	public <T> T fromDocument( Class<T> entityClass, SolrDocument doc ) throws IndexFieldMappingException {
        return fromDocument(entityClass, doc, "*");
    }
    
	/**
	 * Create an object from a SolrDocument.
	 * 
	 * @param entityClass the class of the entity to be instantiated from the node
	 * (in the case of dynamic instantiation, the instance class may be read from
	 * the document, but will be cast to this class)
	 * @param doc the SolrDocument read from the index
     * @param serializedFieldFilter comma separated list of names of serialized fields to load 
	 * ("*" loads all, while "none" loads no children)
	 * @return an object created from the SolrDocument
	 * @throws org.solrom.IndexFieldMappingException
	 */
	public <T> T fromDocument( Class<T> entityClass, SolrDocument doc, String serializedFieldFilter ) throws IndexFieldMappingException {
		if ( !dynamicInstantiation && !isMapped(entityClass) ) {
			throw new IndexFieldMappingException("This class is not mapped by Solrom: " + entityClass.getName());
		}
		
		T obj;
		try {
			obj = (T)createInstance(entityClass, doc);
		} catch ( ClassNotFoundException cnfe ) {
			throw new IndexFieldMappingException(cnfe);
		} catch ( InstantiationException ie ) {
			throw new IndexFieldMappingException(ie);
		} catch ( IllegalAccessException iae ) {
			throw new IndexFieldMappingException(iae);
		}
		fromDocument(obj, doc, serializedFieldFilter);
		return obj;
	}
	
	/**
	 * Set values read from a SolrDocument on an object.
     * All serialized fields will be loaded.
	 *
	 * @param obj the object to set the values on
	 * @param doc the SolrDocument from the index
	 * @throws org.solrom.IndexFieldMappingException
	 */
	public void fromDocument( Object obj, SolrDocument doc ) throws IndexFieldMappingException {
        fromDocument(obj, doc, "*");
    }
    
	/**
	 * Set values read from a SolrDocument on an object.
	 *
	 * @param obj the object to set the values on
	 * @param doc the SolrDocument from the index
     * @param serializedFieldFilter comma separated list of names of serialized fields to load 
	 * ("*" loads all, while "none" loads no children)
	 * @throws org.solrom.IndexFieldMappingException
	 */
    public void fromDocument( Object obj, SolrDocument doc, String serializedFieldFilter ) throws IndexFieldMappingException {
		if ( !isMapped(obj.getClass()) ) {
			throw new IndexFieldMappingException("This class is not mapped by Solrom: " + obj.getClass().getName());
		}
        
        NameFilter nameFilter = new NameFilter(serializedFieldFilter);
		
		// process annotated fields
		for ( Field field : Utils.getDeclaredAndInheritedFields(obj.getClass()) ) {
			field.setAccessible(true);
			if ( field.isAnnotationPresent(IndexField.class) ) {
				IndexField indexField = field.getAnnotation(IndexField.class);
				String name = field.getName();
				if ( !indexField.name().equals("fieldName") ) {
					name = indexField.name();
				}
				try {
                    Object value = doc.getFieldValue(name);
                    if ( value != null ) {
                        if ( field.getType().isEnum() ) {
                            field.set(obj, getEnumValue(field.getType(), (String)value));
                        } else {
                            field.set(obj, value);
                        }
                    }
				} catch ( IllegalAccessException iae ) {
					throw new IndexFieldMappingException(iae);
				}
                
            } else if ( field.isAnnotationPresent(IndexSerializedField.class) && nameFilter.isIncluded(field.getName()) ) {
				IndexSerializedField indexField = field.getAnnotation(IndexSerializedField.class);
				String name = field.getName();
				if ( !indexField.name().equals("fieldName") ) {
					name = indexField.name();
				}
				try {
                    String value = (String)doc.getFieldValue(name);
                    if ( value != null ) {
                        if ( indexField.toXML() ) {
                            field.set(obj, xmlDeSerializeField(value));
                        } else {
                            field.set(obj, deSerializeField(value));
                        }
                    }
				} catch ( IllegalAccessException iae ) {
					throw new IndexFieldMappingException(iae);
				} catch ( IOException ioe ) {
                    throw new IndexFieldMappingException(ioe);
                } catch ( ClassNotFoundException cnfe ) {
                    throw new IndexFieldMappingException(cnfe);
                }
			}
		}
		
		// process annotated setter methods
		for ( Method method : obj.getClass().getMethods() ) {
			if ( method.isAnnotationPresent(IndexFieldSetter.class) ) {
				IndexFieldSetter indexField = method.getAnnotation(IndexFieldSetter.class);
				String name;
				if ( !indexField.name().equals("fieldName") ) {
					name = indexField.name();
				} else {
					name = Utils.getSetterPropertyName(method);
				}
				try {
                    Object value = doc.getFieldValue(name);
                    if ( value != null ) {
                        Class argType = (Class)method.getGenericParameterTypes()[0];
                        if ( argType.isEnum() ) {
                            method.invoke(obj, getEnumValue(argType, (String)value));
                        } else {
                            method.invoke(obj, value);
                        }
                    }
				} catch ( IllegalAccessException iae ) {
					throw new IndexFieldMappingException(iae);
				} catch ( InvocationTargetException ite ) {
					throw new IndexFieldMappingException(ite);
				}
			}
		}
	}
    
    private static Object getEnumValue( Class type, String value ) {
        return Enum.valueOf(type, value);
    }
	
	/**
	 * Map a list of SolrDocuments to a list of objects.
	 * 
	 * @param entityClass the class of the entity to be instantiated from the node
	 * (in the case of dynamic instantiation, the instance class may be read from
	 * the document, but will be cast to this class)
	 * @param docList the list of SolrDocuments read from the index
	 * @return a list of objects created from the SolrDocuments
	 * @throws org.solrom.IndexFieldMappingException
	 */
	public <T> List<T> fromDocumentList( Class<T> entityClass, SolrDocumentList docList ) throws IndexFieldMappingException {
        return fromDocumentList(entityClass, docList, "*");
    }
    
	/**
	 * Map a list of SolrDocuments to a list of objects.
	 * 
	 * @param entityClass the class of the entity to be instantiated from the node
	 * (in the case of dynamic instantiation, the instance class may be read from
	 * the document, but will be cast to this class)
	 * @param docList the list of SolrDocuments read from the index
     * @param serializedFieldFilter comma separated list of names of serialized fields to load 
	 * ("*" loads all, while "none" loads no children)
	 * @return a list of objects created from the SolrDocuments
	 * @throws org.solrom.IndexFieldMappingException
	 */
    public <T> List<T> fromDocumentList( Class<T> entityClass, SolrDocumentList docList, String serializedFieldFilter ) throws IndexFieldMappingException {
		List<T> items = new ArrayList<T>();
		for ( int i = 0; i < docList.size(); i++ ) {
			items.add( fromDocument(entityClass, docList.get(i), serializedFieldFilter) );
		}
		return items;
	}
}
